amalgm 0.1.99 → 0.1.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +343 -0
- package/runtime/scripts/amalgm-mcp/lib/email-md.js +43 -3
- package/runtime/scripts/amalgm-mcp/notify/email-media.js +169 -0
- package/runtime/scripts/amalgm-mcp/notify/index.js +7 -2
- package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +10 -0
- package/runtime/scripts/amalgm-mcp/tests/email-media.test.js +34 -0
- package/runtime/scripts/amalgm-mcp/tests/platform-context.test.js +12 -0
- package/runtime/scripts/platform-context.txt +13 -0
package/package.json
CHANGED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email embed widgets — the email-safe mirror of the UI's shared rendering
|
|
3
|
+
* primitives: FileEmbed (media), EmbedChrome (the floating name/options
|
|
4
|
+
* pills on expanded embeds), and the tile:/full: entity renders.
|
|
5
|
+
*
|
|
6
|
+
* Email clients strip <style>, position:absolute, and most interactivity,
|
|
7
|
+
* so the chrome pills sit in a header row inside the same rounded-16
|
|
8
|
+
* stone-900 card instead of floating over the media — visually the same
|
|
9
|
+
* language, structurally email-safe. Gmail/Outlook drop <video> but render
|
|
10
|
+
* its fallback content; Apple Mail and iOS Mail play video inline.
|
|
11
|
+
*
|
|
12
|
+
* Media table mirrors amalgm-ui/lib/rendering/media.ts; visual values
|
|
13
|
+
* mirror EMAIL_TOKENS in email-md.js / lib/rendering/tokens.ts. Keep all
|
|
14
|
+
* three in sync.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const COLOR = {
|
|
18
|
+
text: '#ffffff',
|
|
19
|
+
textMuted: '#a8a29e', // stone-400
|
|
20
|
+
border: '#1c1917', // stone-900 — container borders
|
|
21
|
+
pillBorder: '#292524', // stone-800
|
|
22
|
+
bg: '#000000',
|
|
23
|
+
bgChip: '#0c0a09', // stone-950
|
|
24
|
+
link: '#60a5fa', // blue-400
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const EXTENSION_MIME = {
|
|
28
|
+
// Images
|
|
29
|
+
png: 'image/png',
|
|
30
|
+
jpg: 'image/jpeg',
|
|
31
|
+
jpeg: 'image/jpeg',
|
|
32
|
+
gif: 'image/gif',
|
|
33
|
+
webp: 'image/webp',
|
|
34
|
+
svg: 'image/svg+xml',
|
|
35
|
+
avif: 'image/avif',
|
|
36
|
+
bmp: 'image/bmp',
|
|
37
|
+
ico: 'image/x-icon',
|
|
38
|
+
// Video
|
|
39
|
+
webm: 'video/webm',
|
|
40
|
+
mp4: 'video/mp4',
|
|
41
|
+
m4v: 'video/mp4',
|
|
42
|
+
mov: 'video/quicktime',
|
|
43
|
+
mkv: 'video/x-matroska',
|
|
44
|
+
ogv: 'video/ogg',
|
|
45
|
+
// Audio
|
|
46
|
+
mp3: 'audio/mpeg',
|
|
47
|
+
wav: 'audio/wav',
|
|
48
|
+
m4a: 'audio/mp4',
|
|
49
|
+
ogg: 'audio/ogg',
|
|
50
|
+
flac: 'audio/flac',
|
|
51
|
+
// Documents
|
|
52
|
+
pdf: 'application/pdf',
|
|
53
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
54
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
55
|
+
xls: 'application/vnd.ms-excel',
|
|
56
|
+
csv: 'text/csv',
|
|
57
|
+
tsv: 'text/tab-separated-values',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function escapeAttr(value) {
|
|
61
|
+
return String(value)
|
|
62
|
+
.replace(/&/g, '&')
|
|
63
|
+
.replace(/</g, '<')
|
|
64
|
+
.replace(/>/g, '>')
|
|
65
|
+
.replace(/"/g, '"');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function escapeText(value) {
|
|
69
|
+
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mediaExtensionOf(nameOrUrl) {
|
|
73
|
+
const withoutQuery = String(nameOrUrl || '').split(/[?#]/)[0];
|
|
74
|
+
const base = withoutQuery.split('/').pop() || '';
|
|
75
|
+
const dot = base.lastIndexOf('.');
|
|
76
|
+
return dot > 0 ? base.slice(dot + 1).toLowerCase() : '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function mediaMimeOf(nameOrUrl) {
|
|
80
|
+
return EXTENSION_MIME[mediaExtensionOf(nameOrUrl)] || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function mediaKindOf(nameOrUrl) {
|
|
84
|
+
const mime = mediaMimeOf(nameOrUrl);
|
|
85
|
+
if (!mime) return 'unknown';
|
|
86
|
+
if (mime.startsWith('image/')) return 'image';
|
|
87
|
+
if (mime.startsWith('video/')) return 'video';
|
|
88
|
+
if (mime.startsWith('audio/')) return 'audio';
|
|
89
|
+
return 'document';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function mediaNameOf(nameOrUrl) {
|
|
93
|
+
const withoutQuery = String(nameOrUrl || '').split(/[?#]/)[0];
|
|
94
|
+
const base = withoutQuery.split('/').pop() || '';
|
|
95
|
+
try {
|
|
96
|
+
return decodeURIComponent(base);
|
|
97
|
+
} catch {
|
|
98
|
+
return base;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isHttpUrl(url) {
|
|
103
|
+
return /^https?:\/\//i.test(url);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Inline attachment reference — the image bytes ride inside the email. */
|
|
107
|
+
function isCidUrl(url) {
|
|
108
|
+
return /^cid:/i.test(url);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Self-contained image source: fetchable over https or embedded as cid. */
|
|
112
|
+
function isEmbeddableImageSrc(url) {
|
|
113
|
+
return isHttpUrl(url) || isCidUrl(url);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- EmbedChrome (email form) ------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function chromePill(content) {
|
|
119
|
+
return `<span style="display:inline-block;background:${COLOR.bgChip};border:1px solid ${COLOR.pillBorder};border-radius:999px;padding:4px 12px;font-size:12px;line-height:16px;color:${COLOR.text};white-space:nowrap">${content}</span>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The expanded-embed card: rounded-16 stone-900 border, black fill, a header
|
|
124
|
+
* row carrying the name pill (left) and an action pill (right), media below.
|
|
125
|
+
* actionLabel+actionHref make the right pill a link; actionText makes it a
|
|
126
|
+
* plain label (e.g. "Attachment").
|
|
127
|
+
*/
|
|
128
|
+
function embedShell({ name, actionLabel, actionHref, actionText, body }) {
|
|
129
|
+
const namePill = name ? chromePill(escapeText(name)) : '';
|
|
130
|
+
const actionPill = actionLabel && actionHref
|
|
131
|
+
? `<a href="${escapeAttr(actionHref)}" target="_blank" style="text-decoration:none">${chromePill(
|
|
132
|
+
`<span style="color:${COLOR.link}">${escapeText(actionLabel)}</span>`,
|
|
133
|
+
)}</a>`
|
|
134
|
+
: actionText
|
|
135
|
+
? chromePill(`<span style="color:${COLOR.textMuted}">${escapeText(actionText)}</span>`)
|
|
136
|
+
: '';
|
|
137
|
+
return (
|
|
138
|
+
`<div style="border:1px solid ${COLOR.border};border-radius:16px;background:${COLOR.bg};margin:16px 0;overflow:hidden">` +
|
|
139
|
+
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
|
|
140
|
+
`<td align="left" style="padding:10px 10px 8px">${namePill}</td>` +
|
|
141
|
+
`<td align="right" style="padding:10px 10px 8px">${actionPill}</td>` +
|
|
142
|
+
`</tr></table>` +
|
|
143
|
+
body +
|
|
144
|
+
`</div>`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Media embeds -------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/** Bare rounded image per the ruleset — images never wear chrome. */
|
|
151
|
+
function renderImageEmbed(alt, url) {
|
|
152
|
+
return `<img src="${escapeAttr(url)}" alt="${escapeAttr(alt || '')}" style="display:block;max-width:100%;height:auto;border-radius:16px;border:1px solid ${COLOR.border};margin:16px 0">`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const PLAY_PILL =
|
|
156
|
+
`<span style="display:inline-block;background:${COLOR.bgChip};border:1px solid ${COLOR.pillBorder};border-radius:999px;padding:10px 20px;font-size:14px;color:${COLOR.text}">▶ Play video</span>`;
|
|
157
|
+
|
|
158
|
+
/** Poster frame + centered play pill, optionally wrapped in a link. */
|
|
159
|
+
function posterBlock(posterSrc, label, href) {
|
|
160
|
+
const img = `<img src="${escapeAttr(posterSrc)}" alt="${escapeAttr(label || 'video')}" style="display:block;width:100%;height:auto;background:${COLOR.bg}">`;
|
|
161
|
+
const pillRow =
|
|
162
|
+
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
|
|
163
|
+
`<td align="center" style="padding:12px 16px 16px;background:${COLOR.bg}">${PLAY_PILL}</td></tr></table>`;
|
|
164
|
+
const inner = img + pillRow;
|
|
165
|
+
return href
|
|
166
|
+
? `<a href="${escapeAttr(href)}" target="_blank" style="text-decoration:none;display:block">${inner}</a>`
|
|
167
|
+
: inner;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Video embed: a real <video> for clients that support it (Apple Mail, iOS
|
|
172
|
+
* Mail), with a fallback that Gmail/Outlook render instead — the poster
|
|
173
|
+
* frame when one is provided, a play card otherwise.
|
|
174
|
+
*/
|
|
175
|
+
function renderVideoEmbed(label, url, poster) {
|
|
176
|
+
const name = label || mediaNameOf(url) || 'Video';
|
|
177
|
+
const safeUrl = escapeAttr(url);
|
|
178
|
+
const posterSrc = poster && isEmbeddableImageSrc(poster) ? poster : null;
|
|
179
|
+
const fallback = posterSrc
|
|
180
|
+
? posterBlock(posterSrc, name, url)
|
|
181
|
+
: `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
|
|
182
|
+
`<td align="center" style="padding:48px 16px;background:${COLOR.bg}">` +
|
|
183
|
+
`<a href="${safeUrl}" target="_blank" style="text-decoration:none">${PLAY_PILL}</a></td></tr></table>`;
|
|
184
|
+
const body =
|
|
185
|
+
`<video controls preload="metadata" src="${safeUrl}"${posterSrc ? ` poster="${escapeAttr(posterSrc)}"` : ''} style="display:block;width:100%;max-height:480px;background:${COLOR.bg}">` +
|
|
186
|
+
fallback +
|
|
187
|
+
`</video>`;
|
|
188
|
+
return embedShell({ name, actionLabel: 'Open ↗', actionHref: url, body });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A video that travels WITH the email as a regular attachment (the
|
|
193
|
+
* self-contained path for local recordings): poster frame in the body when
|
|
194
|
+
* available, and the file itself in the mail client's attachment strip —
|
|
195
|
+
* Gmail gives attached videos a playable preview natively.
|
|
196
|
+
*/
|
|
197
|
+
function renderAttachedVideoCard(label, poster) {
|
|
198
|
+
const name = label || 'Video';
|
|
199
|
+
const posterSrc = poster && isEmbeddableImageSrc(poster) ? poster : null;
|
|
200
|
+
const note =
|
|
201
|
+
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
|
|
202
|
+
`<td align="center" style="padding:${posterSrc ? '0 16px 16px' : '40px 16px 16px'};background:${COLOR.bg}">` +
|
|
203
|
+
(posterSrc ? '' : `${PLAY_PILL}<br>`) +
|
|
204
|
+
`<span style="display:inline-block;padding-top:8px;font-size:12px;color:${COLOR.textMuted}">Attached to this email — open the attachment to play</span>` +
|
|
205
|
+
`</td></tr></table>`;
|
|
206
|
+
const body = (posterSrc
|
|
207
|
+
? `<img src="${escapeAttr(posterSrc)}" alt="${escapeAttr(name)}" style="display:block;width:100%;height:auto;background:${COLOR.bg}">`
|
|
208
|
+
: '') + note;
|
|
209
|
+
return embedShell({ name, actionText: 'Attachment', body });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Non-playable file in an embed position (pdf/docx/audio/anything): the
|
|
214
|
+
* compact file row from FileEmbed, as a card linking out.
|
|
215
|
+
*/
|
|
216
|
+
function renderFileCardEmbed(label, url) {
|
|
217
|
+
const name = label || mediaNameOf(url) || 'File';
|
|
218
|
+
const ext = mediaExtensionOf(name).toUpperCase();
|
|
219
|
+
const inner =
|
|
220
|
+
`<span style="font-size:13px;color:${COLOR.text}">${escapeText(name)}</span>` +
|
|
221
|
+
(ext ? `<span style="font-size:10px;color:${COLOR.textMuted};padding-left:8px">${escapeText(ext)}</span>` : '');
|
|
222
|
+
const body = isHttpUrl(url)
|
|
223
|
+
? `<a href="${escapeAttr(url)}" target="_blank" style="text-decoration:none;display:block;padding:4px 14px 14px">${inner}<span style="font-size:12px;color:${COLOR.link};padding-left:10px">Open ↗</span></a>`
|
|
224
|
+
: `<div style="padding:4px 14px 14px">${inner}<span style="font-size:12px;color:${COLOR.textMuted};padding-left:10px">On your computer — open in amalgm</span></div>`;
|
|
225
|
+
return embedShell({
|
|
226
|
+
name,
|
|
227
|
+
actionLabel: isHttpUrl(url) ? 'Open ↗' : 'amalgm ↗',
|
|
228
|
+
actionHref: isHttpUrl(url) ? url : 'https://amalgm.ai',
|
|
229
|
+
body,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Media dispatch for `` in an email.
|
|
235
|
+
*
|
|
236
|
+
* Self-contained sources produced by the notify media pipeline:
|
|
237
|
+
* cid:<id> — image bytes ride inside the email (inline attachment)
|
|
238
|
+
* attached:<name> — video travels as a regular attachment; the body
|
|
239
|
+
* shows its poster card
|
|
240
|
+
* The optional markdown title carries the video poster (cid: or https).
|
|
241
|
+
* Local sources that could not be attached (amalgm:// refs or filesystem
|
|
242
|
+
* paths) fall back to the file card.
|
|
243
|
+
*/
|
|
244
|
+
function renderMediaEmbed(alt, url, poster) {
|
|
245
|
+
if (isCidUrl(url)) {
|
|
246
|
+
return renderImageEmbed(alt, url);
|
|
247
|
+
}
|
|
248
|
+
const attachedMatch = url.match(/^attached:(.*)$/i);
|
|
249
|
+
if (attachedMatch) {
|
|
250
|
+
let name = attachedMatch[1];
|
|
251
|
+
try {
|
|
252
|
+
name = decodeURIComponent(name);
|
|
253
|
+
} catch {}
|
|
254
|
+
return renderAttachedVideoCard(alt || name, poster);
|
|
255
|
+
}
|
|
256
|
+
const amalgmMatch = url.match(/^amalgm:\/\/file\/([^\s)?"]+)/);
|
|
257
|
+
if (amalgmMatch) {
|
|
258
|
+
let decoded = amalgmMatch[1];
|
|
259
|
+
try {
|
|
260
|
+
decoded = decodeURIComponent(decoded);
|
|
261
|
+
} catch {}
|
|
262
|
+
return renderFileCardEmbed(alt || mediaNameOf(decoded), decoded);
|
|
263
|
+
}
|
|
264
|
+
if (!isHttpUrl(url)) {
|
|
265
|
+
return renderFileCardEmbed(alt || mediaNameOf(url), url);
|
|
266
|
+
}
|
|
267
|
+
const kind = mediaKindOf(url);
|
|
268
|
+
if (kind === 'video') return renderVideoEmbed(alt, url, poster);
|
|
269
|
+
if (kind === 'audio' || kind === 'document') return renderFileCardEmbed(alt, url);
|
|
270
|
+
// Images and extension-less URLs keep vanilla image semantics.
|
|
271
|
+
return renderImageEmbed(alt, url);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// --- Entity embeds (tile: / full:) ---------------------------------------------
|
|
275
|
+
|
|
276
|
+
const ENTITY_TYPE_LABEL = {
|
|
277
|
+
chat: 'Chat',
|
|
278
|
+
conversation: 'Chat',
|
|
279
|
+
automation: 'Automation',
|
|
280
|
+
app: 'App',
|
|
281
|
+
artifact: 'App',
|
|
282
|
+
service: 'App',
|
|
283
|
+
preview: 'Preview',
|
|
284
|
+
task: 'Task',
|
|
285
|
+
event: 'Event',
|
|
286
|
+
trigger: 'Trigger',
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
function entityTypeLabel(type) {
|
|
290
|
+
return ENTITY_TYPE_LABEL[type] || (type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Item');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Inline tile — the email form of the chat's render tile: a compact linked
|
|
295
|
+
* card with a type eyebrow and the entity name, pointing at the amalgm app.
|
|
296
|
+
*/
|
|
297
|
+
function renderEntityTile(label, type) {
|
|
298
|
+
const safeLabel = escapeText(label || entityTypeLabel(type));
|
|
299
|
+
return (
|
|
300
|
+
`<a href="https://amalgm.ai" target="_blank" style="text-decoration:none;display:inline-block;background:${COLOR.bgChip};border:1px solid ${COLOR.pillBorder};border-radius:12px;padding:8px 14px;margin:2px 0;vertical-align:middle">` +
|
|
301
|
+
`<span style="display:block;font-size:11px;line-height:14px;color:${COLOR.textMuted};text-transform:uppercase;letter-spacing:0.04em">${escapeText(entityTypeLabel(type))}</span>` +
|
|
302
|
+
`<span style="display:block;font-size:14px;line-height:20px;color:${COLOR.text}">${safeLabel}</span>` +
|
|
303
|
+
`</a>`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Expanded entity — the email form of the full: render. Email can't iframe
|
|
309
|
+
* the live app, so it's the EmbedChrome card with the entity identity and a
|
|
310
|
+
* prominent open action.
|
|
311
|
+
*/
|
|
312
|
+
function renderEntityExpanded(label, type) {
|
|
313
|
+
const display = label || entityTypeLabel(type);
|
|
314
|
+
const body =
|
|
315
|
+
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>` +
|
|
316
|
+
`<td align="center" style="padding:36px 16px 40px">` +
|
|
317
|
+
`<span style="display:block;font-size:11px;line-height:14px;color:${COLOR.textMuted};text-transform:uppercase;letter-spacing:0.04em;padding-bottom:6px">${escapeText(entityTypeLabel(type))}</span>` +
|
|
318
|
+
`<span style="display:block;font-size:16px;font-weight:650;color:${COLOR.text};padding-bottom:16px">${escapeText(display)}</span>` +
|
|
319
|
+
`<a href="https://amalgm.ai" target="_blank" style="text-decoration:none">` +
|
|
320
|
+
`<span style="display:inline-block;background:${COLOR.bgChip};border:1px solid ${COLOR.pillBorder};border-radius:999px;padding:8px 18px;font-size:13px;color:${COLOR.link}">Open in amalgm ↗</span>` +
|
|
321
|
+
`</a></td></tr></table>`;
|
|
322
|
+
return embedShell({ name: display, actionLabel: 'Open ↗', actionHref: 'https://amalgm.ai', body });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function renderEntityEmbed(mode, type, label) {
|
|
326
|
+
return mode === 'full' ? renderEntityExpanded(label, type) : renderEntityTile(label, type);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = {
|
|
330
|
+
mediaExtensionOf,
|
|
331
|
+
mediaMimeOf,
|
|
332
|
+
mediaKindOf,
|
|
333
|
+
mediaNameOf,
|
|
334
|
+
isHttpUrl,
|
|
335
|
+
isCidUrl,
|
|
336
|
+
isEmbeddableImageSrc,
|
|
337
|
+
renderImageEmbed,
|
|
338
|
+
renderVideoEmbed,
|
|
339
|
+
renderAttachedVideoCard,
|
|
340
|
+
renderFileCardEmbed,
|
|
341
|
+
renderMediaEmbed,
|
|
342
|
+
renderEntityEmbed,
|
|
343
|
+
};
|
|
@@ -11,8 +11,14 @@
|
|
|
11
11
|
* rendering ruleset in amalgm-ui/lib/rendering/tokens.ts — keep both in sync:
|
|
12
12
|
* body 14px, h1 caps at 18px (the amalgm section-header size), Notion-style
|
|
13
13
|
* table grid, black chat-style code blocks, blue hyperlinks/citations.
|
|
14
|
+
*
|
|
15
|
+
* Media (``) and entity renders (`[label](tile:type:id)`,
|
|
16
|
+
* `[label](full:type:id)`) on their own line become block embeds via
|
|
17
|
+
* lib/email-embeds.js — the email mirror of FileEmbed/EmbedChrome/tiles.
|
|
14
18
|
*/
|
|
15
19
|
|
|
20
|
+
const embeds = require('./email-embeds');
|
|
21
|
+
|
|
16
22
|
const EMAIL_TOKENS = {
|
|
17
23
|
font: {
|
|
18
24
|
text: "'SF Pro Text',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif",
|
|
@@ -69,10 +75,28 @@ function renderAmalgmCitation(label, _kindAndPath, description) {
|
|
|
69
75
|
function inlineFormat(text) {
|
|
70
76
|
return (
|
|
71
77
|
text
|
|
72
|
-
//
|
|
78
|
+
// Media mid-sentence: images stay inline; video becomes a play link;
|
|
79
|
+
// local files become a citation-blue span. (Standalone media lines are
|
|
80
|
+
// handled as block embeds in markdownToEmailHtml before reaching here.)
|
|
73
81
|
.replace(
|
|
74
|
-
/!\[([^\]]*)\]\(([^)]+)
|
|
75
|
-
|
|
82
|
+
/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
|
|
83
|
+
(_, alt, url) => {
|
|
84
|
+
if (/^attached:/i.test(url)) {
|
|
85
|
+
return `<span style="color:${EMAIL_TOKENS.color.link}">${escapeHtmlText(alt || 'video')}</span> <span style="color:${EMAIL_TOKENS.color.textSecondary}">(attached)</span>`;
|
|
86
|
+
}
|
|
87
|
+
if (!embeds.isEmbeddableImageSrc(url)) {
|
|
88
|
+
return `<span style="color:${EMAIL_TOKENS.color.link}">${escapeHtmlText(alt || embeds.mediaNameOf(url) || 'file')}</span>`;
|
|
89
|
+
}
|
|
90
|
+
if (embeds.mediaKindOf(url) === 'video') {
|
|
91
|
+
return `<a href="${escapeHtmlAttr(url)}" target="_blank" style="color:${EMAIL_TOKENS.color.link};text-decoration:none">▶ ${escapeHtmlText(alt || 'video')}</a>`;
|
|
92
|
+
}
|
|
93
|
+
return `<img src="${escapeHtmlAttr(url)}" alt="${escapeHtmlAttr(alt)}" style="max-width:100%;height:auto;border-radius:16px;margin:8px 0">`;
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
// Entity renders mid-sentence collapse to the compact tile.
|
|
97
|
+
.replace(
|
|
98
|
+
/\[([^\]]+)\]\((tile|full):([a-zA-Z]+):([^)\s]*)\)/g,
|
|
99
|
+
(_, label, mode, type) => embeds.renderEntityEmbed('tile', type, label),
|
|
76
100
|
)
|
|
77
101
|
// amalgm:// citations (run before generic link regex so the optional
|
|
78
102
|
// title attribute doesn't get swallowed into the href). Format:
|
|
@@ -230,6 +254,22 @@ function markdownToEmailHtml(md) {
|
|
|
230
254
|
out.push(`<hr style="border:none;border-top:1px solid ${EMAIL_TOKENS.color.border};margin:24px 0">`);
|
|
231
255
|
continue;
|
|
232
256
|
}
|
|
257
|
+
// Standalone media line → block embed (image bare, video in the
|
|
258
|
+
// EmbedChrome card, attached/local/doc files as cards). The optional
|
|
259
|
+
// title carries a video poster: .
|
|
260
|
+
const mediaMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/);
|
|
261
|
+
if (mediaMatch) {
|
|
262
|
+
flushAll();
|
|
263
|
+
out.push(embeds.renderMediaEmbed(mediaMatch[1], mediaMatch[2], mediaMatch[3] || null));
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
// Standalone entity render → tile card or expanded card.
|
|
267
|
+
const entityMatch = trimmed.match(/^\[([^\]]+)\]\((tile|full):([a-zA-Z]+):([^)\s]*)\)$/);
|
|
268
|
+
if (entityMatch) {
|
|
269
|
+
flushAll();
|
|
270
|
+
out.push(embeds.renderEntityEmbed(entityMatch[2], entityMatch[3], entityMatch[1]));
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
233
273
|
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
|
234
274
|
if (headingMatch) {
|
|
235
275
|
flushAll();
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained email media pipeline.
|
|
3
|
+
*
|
|
4
|
+
* notify_user messages may embed media that lives on this computer
|
|
5
|
+
* (`` or absolute paths). A mail client can never
|
|
6
|
+
* fetch those, so before rendering we convert them into attachments that
|
|
7
|
+
* travel WITH the email:
|
|
8
|
+
*
|
|
9
|
+
* - images → inline CID attachments; the src is rewritten to `cid:<id>` so
|
|
10
|
+
* the picture displays in the body, fully offline.
|
|
11
|
+
* - videos → regular attachments (Gmail gives those a playable preview);
|
|
12
|
+
* the src is rewritten to `attached:<filename>` which email-embeds
|
|
13
|
+
* renders as the poster card. The poster comes from an explicit markdown
|
|
14
|
+
* title (``), then a sibling animated
|
|
15
|
+
* `<name>.preview.gif` / `<name>.gif`, then a static
|
|
16
|
+
* `<name>.poster.png` / `.jpg` / `.jpeg` / `.webp`, and itself becomes an
|
|
17
|
+
* inline CID.
|
|
18
|
+
*
|
|
19
|
+
* Anything that can't be attached (missing file, too large, over budget) is
|
|
20
|
+
* left untouched so the renderer falls back to its file card. Web https
|
|
21
|
+
* sources are never rewritten.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const os = require('os');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { mediaKindOf, mediaMimeOf } = require('../lib/email-embeds');
|
|
28
|
+
|
|
29
|
+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
30
|
+
const MAX_VIDEO_BYTES = 20 * 1024 * 1024;
|
|
31
|
+
const MAX_TOTAL_BYTES = 22 * 1024 * 1024; // proxy caps at 25MB; leave margin
|
|
32
|
+
const MAX_ATTACHMENTS = 8;
|
|
33
|
+
|
|
34
|
+
const MEDIA_LINE_RE = /^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/;
|
|
35
|
+
|
|
36
|
+
/** Resolve a media src to an absolute path on this computer, or null. */
|
|
37
|
+
function resolveLocalPath(src) {
|
|
38
|
+
if (!src) return null;
|
|
39
|
+
const amalgmMatch = src.match(/^amalgm:\/\/file\/([^?#]+)/);
|
|
40
|
+
if (amalgmMatch) {
|
|
41
|
+
try {
|
|
42
|
+
return decodeURIComponent(amalgmMatch[1]);
|
|
43
|
+
} catch {
|
|
44
|
+
return amalgmMatch[1];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (src.startsWith('file://')) return decodeURIComponentSafe(src.slice('file://'.length));
|
|
48
|
+
let candidate = src;
|
|
49
|
+
try {
|
|
50
|
+
candidate = decodeURIComponent(src);
|
|
51
|
+
} catch {}
|
|
52
|
+
if (candidate.startsWith('~/')) return path.join(os.homedir(), candidate.slice(2));
|
|
53
|
+
if (candidate.startsWith('/')) return candidate;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function decodeURIComponentSafe(value) {
|
|
58
|
+
try {
|
|
59
|
+
return decodeURIComponent(value);
|
|
60
|
+
} catch {
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function statSafe(filePath) {
|
|
66
|
+
try {
|
|
67
|
+
const stat = fs.statSync(filePath);
|
|
68
|
+
return stat.isFile() ? stat : null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sibling preview for a video, best first:
|
|
76
|
+
* - <name>.preview.gif / <name>.gif — animated GIF; Gmail animates inline
|
|
77
|
+
* CID GIFs, so the email "plays" the clip right in the body
|
|
78
|
+
* - <name>.poster.png/jpg/jpeg/webp — static first frame
|
|
79
|
+
* (<name> is the full filename or its stem.)
|
|
80
|
+
*/
|
|
81
|
+
function findSiblingPoster(videoPath) {
|
|
82
|
+
const dir = path.dirname(videoPath);
|
|
83
|
+
const base = path.basename(videoPath);
|
|
84
|
+
const stem = base.replace(/\.[^.]+$/, '');
|
|
85
|
+
const candidates = [];
|
|
86
|
+
for (const name of [base, stem]) {
|
|
87
|
+
candidates.push(path.join(dir, `${name}.preview.gif`), path.join(dir, `${name}.gif`));
|
|
88
|
+
}
|
|
89
|
+
for (const name of [base, stem]) {
|
|
90
|
+
for (const ext of ['png', 'jpg', 'jpeg', 'webp']) {
|
|
91
|
+
candidates.push(path.join(dir, `${name}.poster.${ext}`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return candidates.find((candidate) => statSafe(candidate)) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Convert local media embeds in a markdown message into email attachments.
|
|
99
|
+
* Returns { message, attachments } — message has cid:/attached: sources,
|
|
100
|
+
* attachments are in the proxy's shape (filename, content, contentType,
|
|
101
|
+
* contentId?).
|
|
102
|
+
*/
|
|
103
|
+
function prepareEmailMedia(message) {
|
|
104
|
+
const attachments = [];
|
|
105
|
+
let totalBytes = 0;
|
|
106
|
+
let counter = 0;
|
|
107
|
+
|
|
108
|
+
const budgetAllows = (bytes) =>
|
|
109
|
+
attachments.length < MAX_ATTACHMENTS && totalBytes + bytes <= MAX_TOTAL_BYTES;
|
|
110
|
+
|
|
111
|
+
const attachFile = (filePath, stat, contentId) => {
|
|
112
|
+
const content = fs.readFileSync(filePath).toString('base64');
|
|
113
|
+
totalBytes += stat.size;
|
|
114
|
+
attachments.push({
|
|
115
|
+
filename: path.basename(filePath),
|
|
116
|
+
content,
|
|
117
|
+
contentType: mediaMimeOf(filePath) || 'application/octet-stream',
|
|
118
|
+
...(contentId ? { contentId } : {}),
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const attachPoster = (posterSource) => {
|
|
123
|
+
// Poster may be an explicit local source or an https URL (kept as-is).
|
|
124
|
+
if (!posterSource) return null;
|
|
125
|
+
if (/^https?:\/\//i.test(posterSource) || /^cid:/i.test(posterSource)) return posterSource;
|
|
126
|
+
const posterPath = resolveLocalPath(posterSource);
|
|
127
|
+
const stat = posterPath ? statSafe(posterPath) : null;
|
|
128
|
+
if (!stat || stat.size > MAX_IMAGE_BYTES || !budgetAllows(stat.size)) return null;
|
|
129
|
+
if (mediaKindOf(posterPath) !== 'image') return null;
|
|
130
|
+
const contentId = `poster-${++counter}`;
|
|
131
|
+
attachFile(posterPath, stat, contentId);
|
|
132
|
+
return `cid:${contentId}`;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const lines = message.split('\n').map((line) => {
|
|
136
|
+
const match = line.trim().match(MEDIA_LINE_RE);
|
|
137
|
+
if (!match) return line;
|
|
138
|
+
const [, alt, src, title] = match;
|
|
139
|
+
|
|
140
|
+
if (/^(https?:|cid:|attached:|data:)/i.test(src)) return line;
|
|
141
|
+
const filePath = resolveLocalPath(src);
|
|
142
|
+
const stat = filePath ? statSafe(filePath) : null;
|
|
143
|
+
if (!stat) return line;
|
|
144
|
+
|
|
145
|
+
const kind = mediaKindOf(filePath);
|
|
146
|
+
|
|
147
|
+
if (kind === 'image') {
|
|
148
|
+
if (stat.size > MAX_IMAGE_BYTES || !budgetAllows(stat.size)) return line;
|
|
149
|
+
const contentId = `media-${++counter}`;
|
|
150
|
+
attachFile(filePath, stat, contentId);
|
|
151
|
+
return ``;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (kind === 'video') {
|
|
155
|
+
if (stat.size > MAX_VIDEO_BYTES || !budgetAllows(stat.size)) return line;
|
|
156
|
+
attachFile(filePath, stat, null);
|
|
157
|
+
const poster = attachPoster(title) || attachPoster(findSiblingPoster(filePath));
|
|
158
|
+
const label = alt || path.basename(filePath);
|
|
159
|
+
const target = `attached:${encodeURIComponent(path.basename(filePath))}`;
|
|
160
|
+
return poster ? `` : ``;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return line;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return { message: lines.join('\n'), attachments };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { prepareEmailMedia };
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const { PROXY_BASE_URL, PROXY_TOKEN } = require('../config');
|
|
10
10
|
const { ensureFreshProxyToken, readProxyToken } = require('../../proxy-token-store');
|
|
11
11
|
const { formatNotificationEmail } = require('../lib/email-md');
|
|
12
|
+
const { prepareEmailMedia } = require('./email-media');
|
|
12
13
|
const { textResult, errorResult } = require('../lib/tool-result');
|
|
13
14
|
|
|
14
15
|
async function notifyUserViaProxy({ subject: customSubject, message, link, sessionId }) {
|
|
@@ -27,7 +28,10 @@ async function notifyUserViaProxy({ subject: customSubject, message, link, sessi
|
|
|
27
28
|
|
|
28
29
|
const subject =
|
|
29
30
|
(customSubject && customSubject.trim()) || message.slice(0, 80).replace(/\n/g, ' ');
|
|
30
|
-
|
|
31
|
+
// Local media embeds become attachments riding inside the email: images
|
|
32
|
+
// inline via cid:, videos as regular attachments with a poster card.
|
|
33
|
+
const media = prepareEmailMedia(message);
|
|
34
|
+
const html = formatNotificationEmail(media.message, link || null);
|
|
31
35
|
|
|
32
36
|
try {
|
|
33
37
|
const res = await fetch(notifyUrl, {
|
|
@@ -40,6 +44,7 @@ async function notifyUserViaProxy({ subject: customSubject, message, link, sessi
|
|
|
40
44
|
subject,
|
|
41
45
|
html,
|
|
42
46
|
text: message,
|
|
47
|
+
...(media.attachments.length ? { attachments: media.attachments } : {}),
|
|
43
48
|
...(sessionId ? { sessionId } : {}),
|
|
44
49
|
}),
|
|
45
50
|
});
|
|
@@ -64,7 +69,7 @@ module.exports = [
|
|
|
64
69
|
{
|
|
65
70
|
name: 'notify_user',
|
|
66
71
|
description:
|
|
67
|
-
'Send a notification to the user via email (from emails@amalgm.ai). Use this to keep the user informed about important results, completions, or errors — especially during scheduled tasks, event triggers, and long-running operations where the user is likely not actively watching.\n\nBest practices:\n- Always call notify_user at the end of a scheduled task or event-triggered run.\n- For errors, include enough context that the user can act without opening the session.\n- Keep it scannable: lead with what happened, then details.\n\nFormatting: The message is rendered as full markdown. You can use headings (##), **bold**, *italic*, `inline code`, ```code blocks```, [links](url), lists (- or 1.), blockquotes (>), and --- horizontal rules. Write it like a concise status update — no greeting, no sign-off, just the information.',
|
|
72
|
+
'Send a notification to the user via email (from emails@amalgm.ai). Use this to keep the user informed about important results, completions, or errors — especially during scheduled tasks, event triggers, and long-running operations where the user is likely not actively watching.\n\nBest practices:\n- Always call notify_user at the end of a scheduled task or event-triggered run.\n- For errors, include enough context that the user can act without opening the session.\n- Keep it scannable: lead with what happened, then details.\n\nFormatting: The message is rendered as full markdown. You can use headings (##), **bold**, *italic*, `inline code`, ```code blocks```, [links](url), tables, lists (- or 1.), blockquotes (>), and --- horizontal rules. Rich embeds on their own line: `` with a local image attaches it INSIDE the email and displays it inline; with a local video (webm/mp4/mov, ≤20MB) the file travels as a real attachment and the body shows a poster card. Poster preference: explicit markdown title ``, then sibling `<name>.preview.gif` / `<name>.gif` for Gmail-friendly motion, then static `<name>.poster.png` / `.jpg` / `.jpeg` / `.webp`. `` embeds web media (video player with play-card fallback). `[label](tile:TYPE:ID)` / `[label](full:TYPE:ID)` render entity cards linking back to amalgm. Write it like a concise status update — no greeting, no sign-off, just the information.',
|
|
68
73
|
inputSchema: {
|
|
69
74
|
type: 'object',
|
|
70
75
|
properties: {
|
|
@@ -44,6 +44,16 @@ test('core tool actions carry stable toolbox ids', () => {
|
|
|
44
44
|
assert.equal(notifyUser.toolboxActionId, 'notifications.notify_user');
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
test('notify_user description teaches self-contained video embeds with GIF previews', () => {
|
|
48
|
+
const notifyUser = CORE_TOOLS.find((tool) => tool.name === 'notify_user');
|
|
49
|
+
|
|
50
|
+
assert.equal(notifyUser.description.includes(''), true);
|
|
51
|
+
assert.equal(notifyUser.description.includes('<name>.preview.gif'), true);
|
|
52
|
+
assert.equal(notifyUser.description.includes('<name>.gif'), true);
|
|
53
|
+
assert.equal(notifyUser.description.includes('<name>.poster.png'), true);
|
|
54
|
+
assert.equal(notifyUser.description.includes('≤20MB'), true);
|
|
55
|
+
});
|
|
56
|
+
|
|
47
57
|
test('browser is a single toolbox entry holding every browser action', () => {
|
|
48
58
|
const browser = CORE_TOOL_GROUPS.find((group) => group.id === 'browser');
|
|
49
59
|
assert.deepEqual(browser.tools.map((tool) => tool.name), [
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const { prepareEmailMedia } = require('../notify/email-media');
|
|
10
|
+
|
|
11
|
+
test('prepareEmailMedia attaches local video and prefers sibling animated GIF preview', () => {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-email-media-'));
|
|
13
|
+
try {
|
|
14
|
+
const videoPath = path.join(dir, 'qa-pass.webm');
|
|
15
|
+
const gifPath = path.join(dir, 'qa-pass.preview.gif');
|
|
16
|
+
const staticPosterPath = path.join(dir, 'qa-pass.poster.png');
|
|
17
|
+
fs.writeFileSync(videoPath, Buffer.from('not really a webm but enough for extension detection'));
|
|
18
|
+
fs.writeFileSync(gifPath, Buffer.from('GIF89a'));
|
|
19
|
+
fs.writeFileSync(staticPosterPath, Buffer.from('png fallback should not be chosen'));
|
|
20
|
+
|
|
21
|
+
const src = `amalgm://file/${encodeURIComponent(videoPath)}`;
|
|
22
|
+
const result = prepareEmailMedia(`Here is the recording:\n`);
|
|
23
|
+
|
|
24
|
+
assert.match(result.message, /!\[QA recording\]\(attached:qa-pass\.webm "cid:poster-1"\)/);
|
|
25
|
+
assert.equal(result.attachments.length, 2);
|
|
26
|
+
assert.equal(result.attachments[0].filename, 'qa-pass.webm');
|
|
27
|
+
assert.equal(result.attachments[0].contentType, 'video/webm');
|
|
28
|
+
assert.equal(result.attachments[1].filename, 'qa-pass.preview.gif');
|
|
29
|
+
assert.equal(result.attachments[1].contentType, 'image/gif');
|
|
30
|
+
assert.equal(result.attachments[1].contentId, 'poster-1');
|
|
31
|
+
} finally {
|
|
32
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -44,3 +44,15 @@ test('platform context browser names match the registered actions', () => {
|
|
|
44
44
|
assert.equal(text.includes(`\`${name}\``), true, `prompt should mention ${name}`);
|
|
45
45
|
}
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
test('platform context teaches rich media embeds with GIF video previews', () => {
|
|
49
|
+
const text = fs.readFileSync(CONTEXT_PATH, 'utf8');
|
|
50
|
+
|
|
51
|
+
assert.equal(text.includes('## Embedding Images and Videos'), true);
|
|
52
|
+
assert.equal(text.includes(''), true);
|
|
53
|
+
assert.equal(text.includes('amalgm://file/ENCODED_ABSOLUTE_PATH'), true);
|
|
54
|
+
assert.equal(text.includes('<name>.preview.gif'), true);
|
|
55
|
+
assert.equal(text.includes('<name>.gif'), true);
|
|
56
|
+
assert.equal(text.includes('<name>.poster.png'), true);
|
|
57
|
+
assert.equal(text.includes('≤20MB'), true);
|
|
58
|
+
});
|
|
@@ -203,6 +203,19 @@ Types:
|
|
|
203
203
|
- App: [label](full:app:APP_ID)
|
|
204
204
|
- Preview: [label](full:preview:PORT)
|
|
205
205
|
|
|
206
|
+
## Embedding Images and Videos
|
|
207
|
+
|
|
208
|
+
Markdown image syntax is the embed syntax: `` on its own line renders the media inline — in chat messages AND in notify_user emails. Use it to show screenshots, demo clips, and QA recordings instead of describing them.
|
|
209
|
+
|
|
210
|
+
Sources:
|
|
211
|
+
- Local file: `` — percent-encode the path exactly like a file citation. Videos (webm/mp4/mov) get an inline player with name + options chrome; images render bare and rounded. PDFs, spreadsheets, and docx embed as document previews the same way.
|
|
212
|
+
- Web URL: `` — the extension decides the embed (video player for .webm/.mp4/.mov, image otherwise).
|
|
213
|
+
|
|
214
|
+
Rules:
|
|
215
|
+
- Put embeds on their own line. Mid-sentence, prefer a normal `amalgm://` file citation instead.
|
|
216
|
+
- Emails are self-contained: a local image embed is attached inside the email and displays inline; a local video (≤20MB) travels as a real attachment with a poster card in the body. Poster preference: explicit markdown title ``, then sibling `<name>.preview.gif` / `<name>.gif` for Gmail-friendly motion, then static `<name>.poster.png` / `.jpg` / `.jpeg` / `.webp`. Oversized or missing files degrade to a file card. Entity references (`tile:`/`full:`) work in email too, as cards.
|
|
217
|
+
- Don't wrap embeds in backticks. A browser recording you just captured is a great thing to embed in your reply or in a QA report email.
|
|
218
|
+
|
|
206
219
|
## Citing Files, Folders, Skills, and Agents
|
|
207
220
|
|
|
208
221
|
When you reference a file, folder, skill, or agent that exists on the user's local volume, emit it as an `amalgm://` markdown link. The frontend renders these as inline citation chips by default, with a hover card showing the path and (for skills) description. Skills are files too: if you only have the `SKILL.md` path, a normal file citation is valid. This is the same format the user's `@`-menu produces, and it's how agents talk to each other about local resources.
|